Ensure you have a posgresql server running
add env variables for
z.object({
GITHUB_CLIENT_ID: z.string().min(5),
GITHUB_CLIENT_SECRET: z.string().min(5),
BETTER_AUTH_SECRET: z.string().min(10),
API_URL: z.string().url(),
BETTER_AUTH_URL: z.string().url(),
NODE_ENV: z.string().default("development"),
PORT: z.coerce.number().default(5000),
LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]),
DATABASE_URL: z.string().url(),
FRONTEND_URL: z.string(),
});
npm run drizzle:push
npm run dev
open api reference UI is on
http://localhost:5000/reference
open api documnetation is on
http://localhost:5000/doc
Uses
Honojs is just like express witha key diffecrence of having the Request
and Response
be inside the context
import { Hono } from "hono";
const app = new Hono();
app.get("/", (c) => {
// c.req :request
// c.res: response
// c.var: async local storage values
// c.env : enviroment specific methods (nodejs,f=deno,cf workers...)
return c.text("Hono!");
return c.json({ message: "Hono!" });
});
export default app;
The entry point for this app is apps/hono/src/index.ts
which import the actual app setup with routes and middleware , This is to make testing esaiser
export function createApp() {
const app = createRouter();
app.use(
"*",
cors({
origin: [...allowedOrigins],
allowHeaders: ["Content-Type", "Authorization"],
allowMethods: ["POST", "GET", "OPTIONS", "PUT", "DELETE", "PATCH"],
exposeHeaders: ["Content-Length"],
maxAge: 600,
credentials: true,
})
); // enable cors with support for cross site httpOnly cookies
app.use(requestId()); // adds the requset id (for logging)
app.use(pinoLogger()); // logging middleware
app.use(contextStorage()); // initializes async local storage
app.use("/api/users/*", (c, next) => authenticateUserMiddleware(c, next)); // auth gurad to only allow logged in users
app.use("/api/auditlogs/*", (c, next) => authenticateUserMiddleware(c, next, "admin")); // auth gaurd to only allow admin users
app.use(serveEmojiFavicon("📝")); // adds the emoji as favioc
app.notFound(notFound); // global not found handler
app.onError(onHonoError); // global error handler
return app;
}
Drizzle is used to manage the database schemas and migrations
// example schema
import { boolean, decimal, integer, pgTable, text } from "drizzle-orm/pg-core";
import { commonColumns } from "../helpers/columns";
export const helloTable = pgTable("hello", {
...commonColumns,
name: text().notNull(),
email: text().notNull().unique(),
password: text().notNull(),
avatarUrl: text(),
refreshToken: text(),
});
with commands
"drizzle:gen": "drizzle-kit generate",// generates the migratons sql files
"drizzle:migrate": "drizzle-kit migrate ", // runs the migrations
"drizzle:push": "drizzle-kit push ", //push the changes to the database directly
"drizzle:studio": "drizzle-kit studio",// open the drizzle studio to visualize you db
coupled with drizzle-zod
to generate the zod schemas for the tables
export const helloSelectSchema = createSelectSchema(helloTable);
export const helloInsertSchema = createInsertSchema(helloTable);
export const helloUpdateSchema = createUpdateSchema(helloTable);
The zod schemas are the used with hono-zod-openapi
to validate requests and responses and generate athe swagger doc
export function configureOpenAPI(app: AppOpenAPI) {
app.doc("/doc", {
openapi: "3.0.0",
info: {
version: packageJSON.version,
title: "Inventory API",
},
});
app.get(
"/reference",
apiReference({
theme: "kepler",
layout: "classic",
defaultHttpClient: {
targetKey: "javascript",
clientKey: "fetch",
},
spec: {
url: "/doc",
},
})
);
}
example route
// index.ts
const route = createRouter().openapi(
createRoute({
tags: ["Home"],
method: "get",
path: "/api/v1",
responses: {
[HttpStatusCodes.OK]: jsonContent(
baseResponseSchema.extend({
result: z.object({
message: z.string(),
}),
error: z.null().optional(),
}),
"Welcome to the Inventory API"
),
},
}),
async (c) => {
return c.json(
{
error: null,
result: {
message: "Welcome to the Inventory API",
},
},
HttpStatusCodes.OK
);
}
);
// app.ts
const app = createApp();
app.route("/", route);
export default app;
// import { serve } from "@hono/node-server";
// import app from "./app";
// import { envVariables } from "./env";
const port = envVariables.PORT;
// eslint-disable-next-line no-console
console.log(`Server is running on port http://localhost:${port}`);
serve({
fetch: app.fetch,
port,
});
uses pino
coupled with the honojs logger middleware which is passed down using hono/context
(a wrapper around nodejs AsyncLocalStorage
which makes avaiable accrioo the app by calling
c.var.logger.info("message");
Most of the data is fetchec through the BaseCrudService
To make pagination and error handling easy the list endpoints respond with
interface SuccessListResponse<T> {
error: null;
result: {
page: number;
perPage: number;
totalItems: number;
totalPages: number;
items: T[];
};
}
interface ErrorResponse<T> {
error: {
messgae: string;
status: number;
data: Array<record<string, any>>;
};
result: null;
}
This resultor error pattern applie to all routes ,
base-crup-service
an abstraction to help quickey scafold api routes of GET
, POST
, PUT
, DELETE
and PATCH
it can be extended for custom behavior
// categories required to be filtered by name or categoryId
export class CategoriesService extends BaseCrudService<
typeof categoriesTable,
z.infer<typeof categoriesInsertSchema>,
z.infer<typeof categoriesUpdateSchema>
> {
constructor() {
super(categoriesTable, entityType.CATEGORY);
}
// Override or add custom methods
override async findAll(query: z.infer<typeof listCategoriesQueryParamsSchema>) {
const { search, ...paginationQuery } = query;
const conditions = or(
search ? ilike(categoriesTable.name, `%${search}%`) : undefined,
search ? ilike(categoriesTable.id, `%${search}%`) : undefined
);
return super.findAll(paginationQuery, conditions);
}
}
Inside this abstraction audit logs and caching is perfoemd to increase DRY
ness
// example of audit logiing,structured logging and caching
export class BaseCrudService<
T extends PgTable<any>,
CreateDTO extends Record<string, any>,
UpdateDTO extends Record<string, any>,
> {
protected table: T;
protected entityType: EntityType;
private auditLogService: AuditLogService;
constructor(table: T, entityType: EntityType) {
this.table = table;
this.entityType = entityType;
this.auditLogService = new AuditLogService();
}
async findById(id: string): Promise<FindOneReturnType<T>["item"]> {
const c = getContext<AppBindings>();
const cacheKey = `findById:${id}`;
const cachedResult = await cacheService.get(cacheKey);
if (cachedResult) {
c.var.logger.info(`Cache hit for ${cacheKey}`);
return JSON.parse(cachedResult);
}
c.var.logger.warn(`Cache miss for ${cacheKey}`);
const item = await db
.select()
.from(this.table)
// TODO : extend type PgTable with a narrower type which always has an ID column
// @ts-expect-error : the type is too genrric but shape matches
.where(eq(this.table.id, id))
.limit(1);
const result = item[0];
await cacheService.set(cacheKey, JSON.stringify(result), 60 * 5); // Cache for 5 minutes
c.var.logger.info(`Cache set for ${cacheKey}`);
return result;
}
async create(data: CreateDTO) {
const ctx = getContext<AppBindings>();
const userId = ctx.var.viewer?.id;
const item = await db
.insert(this.table)
.values(data as any)
.returning();
await this.auditLogService.create({
userId,
action: auditAction.CREATE,
entityType: this.entityType,
entityId: item[0].id,
newData: data,
});
return item[0];
}
}
The structred logs could be vased to disk later on but the audit logs are saved to the DB
The app can run using local nodejs but a docker setup is also possible
[!WARNING]
This is an adapted dockerfile from another project and it may not work as expected[!NOTE]
These commands should be run from the root directory
sudo docker build -t hono -f apps/hono/Dockerfile .
sudo docker run -d \
--name hono-api \
-p 5000:80 \
hono
sudo docker ps
sudo docker logs hono-api
Access the application
Open browser at http://localhost:5000
Stop and remove the container
sudo docker stop hono-api
sudo docker rm hono-api